9장 표준 틀 밖의 맞춤 동작 (입문판)

원서: API Design Patterns (JJ Geewax, Manning) 9장을 입문자용으로 쉽게 풀어 씀

이 장에서 딱 5가지만

  1. 표준 메서드(만들기·읽기·고치기·지우기)로 안 되는 동작이 있다.
  2. 그럴 때 쓰는 게 사용자 메서드다. POST + 콜론(:)으로 만든다.
  3. 사용자 메서드는 부수 효과(이메일 발송 같은 옆동작)를 해도 된다.
  4. 대상이 하나면 리소스, 여럿이면 컬렉션을 겨눈다.
  5. 저장 안 하는 무상태 메서드(번역 같은)도 사용자 메서드로 만든다.

학습 목표

이 장을 마치면 다음을 할 수 있다.

  • 표준 메서드로 처리하면 안 되는 동작이 어떤 것인지 설명한다.
  • 사용자 메서드가 무엇이고 어떻게 생겼는지 설명한다.
  • 부수 효과가 표준 메서드와 사용자 메서드에서 어떻게 다른지 구분한다.
  • 리소스 대상·컬렉션 대상·무상태 메서드를 구분한다.
  • 사용자 메서드를 남용하면 안 되는 이유를 설명한다.

9.1 표준 메서드만으로는 안 되는 순간

이거 이렇게 짜본 적 있죠?

이메일 앱을 만든다. 이메일에는 "초안 / 보냄" 같은 상태가 있다. 사용자가 "보내기" 버튼을 누르면, 상태를 "보냄"으로 바꿔야 한다. 그래서 이렇게 짠다.

email = GetEmail({id: 1});          // 지금 상태: "초안"
UpdateEmail({id: 1, state: "sent"}); // 상태를 "보냄"으로 바꿈

여기서 문제가 터진다. 상태를 "보냄"으로 바꾸기만 하면 끝이 아니다. 진짜로 이메일이 수신자에게 날아가야 한다. 그러니까 UpdateEmail 안에서 몰래 SMTP 서버로 이메일을 쏘게 된다. (SMTP = 이메일을 실제로 배달해주는 우체국 서버. 0장 용어집 참고.)

그게 바로 표준 메서드의 한계다.

표준 메서드가 뭐였더라

7장에서 배운 표준 메서드는 5가지였다. 만들기(Create), 읽기(Get), 고치기(Update), 지우기(Delete), 목록(List). 이걸 묶어서 CRUD라고 부른다. (CRUD = Create·Read·Update·Delete 앞글자. "데이터를 만들고·읽고·고치고·지우는" 기본 4동작. 0장 용어집 참고.)

이 5가지는 강력하다. 하지만 실무에서는 이 다섯으로 깔끔하게 표현 안 되는 동작이 자꾸 나온다.

비유: 기본 공구 세트

표준 메서드 = 드라이버, 망치, 펜치, 톱, 줄자 (기본 5종 공구). 콘크리트 벽에 구멍을 뚫어야 하는데 드라이버로 박박 긁고 있으면? 결과가 엉망이 된다. 이럴 땐 전동 드릴이라는 특수 도구가 따로 필요하다.

비유 API/코드 위험·주의
드라이버로 콘크리트 뚫기 UpdateEmail로 이메일 발송 처리 도구를 억지로 쓰면 동작이 망가짐
전동 드릴 (특수 도구) 사용자 메서드 SendEmail 맞는 도구를 따로 만들면 깔끔

왜 UpdateEmail로 보내면 안 되나 — 문제 3가지

문제 1. 의미가 왜곡된다

"상태 필드를 sent로 바꿔주세요"와 "이 이메일을 진짜로 수신자에게 보내주세요"는 완전히 다른 말이다.

앞은 그냥 데이터 한 칸 수정이고, 뒤는 우체국 서버를 거쳐 실제로 편지를 부치는 큰 동작이다. 그런데 UpdateEmail이라는 한 이름이 두 가지를 다 떠맡으면, 보는 사람이 헷갈린다.

문제 2. 부수 효과가 끼어든다

부수 효과(Side Effect)는 "주 작업 말고 추가로 일어나는 옆동작"이다. (부수 효과 = 본래 시키려던 일 외에 덤으로 벌어지는 일. 라면 끓이려다 주방까지 데우는 격. 0장 용어집 참고.)

UpdateEmail은 원래 "데이터만 고쳐야" 한다. 그런데 실제로는 이렇게 된다.

1. DB에 상태 저장        (스토리지 통신)
2. SMTP 서버로 발송      (외부 통신)
3. 수신자 쪽에 알림       (추가 통신)

단순 수정이 아니라, 세 시스템을 지휘하는 복잡한 일이 돼버린다.

문제 3. 예측이 안 된다

사용자가 단지 이메일 제목만 고치고 싶었는데, 실수로 state 칸을 건드리면? 의도치 않게 이메일이 발송돼버린다.

게다가 발송에는 시간이 걸린다. 그러면 "어? UpdateEmail이 왜 이렇게 느려?" 하고 당황하게 된다.

그럼 리소스를 쪼개서 풀면 안 될까

한 가지 꼼수가 있다. "초안"과 "보낸 이메일"을 아예 다른 리소스로 나누는 것이다.

EmailDraft  →  편집 가능한 초안
Email       →  만드는 순간 곧 발송 (변경 불가)

초안을 EmailDraft로 만들어 마음껏 고치다가, Email을 새로 "만들면(Create)" 그게 곧 발송이 되도록 설계할 수 있다.

이렇게 표준 메서드만으로 끼워 맞출 수는 있다. 하지만 모든 동작을 표준 메서드 틀에 욱여넣으려는 건 일종의 강박이다. API의 목표는 표현이 잘 되고, 단순하고, 예측 가능한 인터페이스를 만드는 것이다.

한 걸음 더 ▸ 강박을 내려놓기

리소스를 잘게 쪼개면 표준 메서드로 풀 수 있는 경우도 분명히 있다. 그런데 그게 부자연스러우면(설계가 비틀리면) 굳이 고집할 필요 없다. 그냥 별도의 사용자 메서드를 하나 만들어서 그 동작만 깔끔히 맡기면 된다.


9.2 사용자 메서드란

자, 그래서 등장하는 게 사용자 메서드다.

방금 본 SendEmail처럼, 표준 메서드 다섯으로는 표현 못 하는 API 호출. 그게 사용자 메서드(Custom Method)다. (사용자 메서드 = CRUD 틀 밖의 특별한 동작을 위해 직접 만든 API. 0장 용어집 참고.)

비유: 레스토랑 메뉴

표준 메서드 = 정규 메뉴 (스테이크, 파스타, 샐러드, 수프, 디저트). 이름만 봐도 뭔지 안다. 대신 메뉴에 없는 건 못 시킨다.

사용자 메서드 = 셰프 특별 메뉴 (오늘의 요리). 이름만으론 내용을 바로 알기 어렵다. 대신 정규 메뉴로 못 만드는 특별한 요리를 낸다. 단, 레스토랑(API) 전체에서 스타일은 일관되게 유지해야 한다.

비유 API/코드 위험·주의
정규 메뉴 표준 메서드 (CRUD) 이름만 봐도 알지만 못 하는 게 있음
셰프 특별 메뉴 사용자 메서드 (Custom) 자유롭지만 문서를 봐야 이해됨

둘을 표로 비교

구분 표준 메서드 사용자 메서드
목적 CRUD (데이터 접근·조작) 비표준 동작 (상태 전환, 외부 통신 등)
규칙 많음 (이름·HTTP·동작 다 정해짐) 적음 (자유롭게 설계)
예측 가능성 높음 (이름만 봐도 추측) 낮음 (문서를 봐야 이해)
부수 효과 금지 허용
HTTP 메서드 동작별로 다름 (GET·POST·PATCH 등) 거의 항상 POST
URL 모양 GET /users/1/emails/2 POST /users/1/emails/2:send

사용자 메서드가 필요한 4가지 상황

1. 상태 전환
   이메일 "초안" → "보냄" → "보관"
   주문 "대기" → "배송중" → "완료"

2. 부수 효과가 필요한 동작
   이메일 전송 (SMTP 통신)
   결제 처리 (PG사 통신)
   푸시 알림 발송

3. 계산·변환 작업
   텍스트 번역
   이메일 주소 검증
   이미지 리사이즈

4. 일괄 처리 (Batch)
   전체 이메일 내보내기
   여러 리소스 한번에 보관

코드로 보기 — 로켓 발사

사용자 메서드는 이렇게 생겼다. 짧다.

@post("/{id=rockets/*}:launch")
LaunchRocket(req: LaunchRocketRequest): Rocket;

:launch가 핵심이다. 콜론 뒤에 동작 이름을 붙인다.

이름 짓기 규칙: <동사><명사> 형태

올바른 예: LaunchRocket (Launch + Rocket), ArchiveDocument. 잘못된 예: CreateRocketForMars (for 같은 전치사 금지), DoLaunch (Do 처럼 두루뭉술한 동사 금지).

비유 API/코드 위험·주의
"발사 버튼" 한 개 LaunchRocket (동사+명사) 동작이 한눈에 보임
"그거 좀 해줘" DoLaunch (모호한 동사) 무슨 동작인지 알 수 없음

9.3 실제로 어떻게 만드나

URL 구조 뜯어보기

POST  /rockets/1234567 : launch
 │     └──────────────┘  └────┘
 │        리소스 경로      동작명
HTTP 메서드

POST를 쓰고, 리소스 경로 뒤에 콜론(:)을 찍고, 동작 이름을 붙인다.

왜 POST를 쓰나

HTTP 메서드마다 정해진 뜻이 있다.

GET    → 데이터 조회 (옆동작 없어야 함)
POST   → "이 데이터로 뭔가 처리해줘"   ← 가장 유연
PUT    → 전체 교체
PATCH  → 부분 수정
DELETE → 삭제

사용자 메서드는 발사하고, 보내고, 번역하고... 별의별 동작을 다 한다. 그래서 제일 범용적인 POST가 맞다.

한 걸음 더 ▸ GET을 쓰는 예외

사용자 메서드가 "읽기만 하고 아무것도 안 바꾸고(안전), 몇 번 호출해도 결과가 같다(멱등성)"면 GET을 써도 된다. (멱등성 = 같은 요청을 여러 번 보내도 결과가 똑같은 성질. 엘리베이터 버튼을 10번 눌러도 한 번 누른 것과 같은 격. 0장 용어집 참고.) 다만 이런 경우는 드물다. 일단은 POST가 기본 안전판이라고 생각하면 된다.

콜론(:)을 쓰는 이유

슬래시(/) 대신 콜론(:)을 쓴다. 왜?

POST /rockets/1234567/launch    ← 슬래시 (잘못됨)
  → launch가 하위 리소스처럼 보임
  → "/launch 밑에 또 뭔가 있나?" 하고 헷갈림

POST /rockets/1234567:launch    ← 콜론 (올바름)
  → 리소스(1234567)는 여기서 끝, 동작(launch) 시작
  → URL이 좀 이상해 보여도 의미는 명확

비유: 문장 부호

"서울역 출발" 과 "서울역: 출발". 콜론이 "여기부터 새로운 내용(동작)" 이라고 선을 그어준다.

비유 API/코드 위험·주의
"서울역 출발" (구분 없음) /rockets/1234567/launch 어디까지가 장소인지 모호
"서울역: 출발" (콜론) /rockets/1234567:launch 경로 끝과 동작 시작이 명확

Flask로 직접 짜보기 (worked example)

이메일을 만들고(표준), 보내는(사용자) 코드를 같이 본다. 먼저 표준 메서드들이다.

# 표준 메서드: 이메일 생성 — 항상 "초안"으로
@app.route("/users/<user_id>/emails", methods=["POST"])
def create_email(user_id):
    data = request.json
    email_id = str(len(emails_db) + 1)
    emails_db[email_id] = {"id": email_id,
        "subject": data.get("subject", ""),
        "content": data.get("content", ""),
        "state": "draft"}     # 만들 때는 무조건 초안
    return jsonify(emails_db[email_id]), 201

여기서 핵심은 update가 "상태를 바꾸지 못하게" 막는 것이다.

# 표준 메서드: 수정 — 제목·내용만, state는 절대 못 건드림
@app.route("/users/<user_id>/emails/<email_id>", methods=["PATCH"])
def update_email(user_id, email_id):
    email = emails_db.get(email_id)
    if not email:
        return jsonify({"error": "없는 이메일"}), 404
    data = request.json
    if "subject" in data:
        email["subject"] = data["subject"]
    if "content" in data:
        email["content"] = data["content"]
    # state 는 여기서 안 바꾼다! (발송은 사용자 메서드의 몫)
    return jsonify(email)

이제 발송은 사용자 메서드로 따로 뺀다. 콜론 :send에 주목.

# 사용자 메서드: 이메일 전송 — 부수 효과 있음
@app.route("/users/<user_id>/emails/<email_id>:send", methods=["POST"])
def send_email(user_id, email_id):
    email = emails_db.get(email_id)
    if email["state"] == "sent":
        return jsonify({"error": "이미 보냄"}), 400
    success = _send_via_smtp(email)   # ① SMTP로 진짜 발송 (부수 효과)
    if not success:
        return jsonify({"error": "전송 실패"}), 500
    email["state"] = "sent"           # ② 그다음 상태 변경
    return jsonify(email)

같은 식으로 이런 사용자 메서드들도 만들 수 있다.

@app.route(".../emails/<email_id>:unsend", methods=["POST"])
# 지연 전송 중인 이메일을 취소 → state 다시 "draft"

@app.route(".../emails/<email_id>:undelete", methods=["POST"])
# 삭제된 이메일 복구 → deleted = False

@app.route(".../emails:export", methods=["POST"])
# 컬렉션 대상! 전체 이메일을 파일로 내보내기

9.3.1 부수 효과 — 표준과 사용자의 가장 큰 차이

여기가 핵심이다. 둘의 가장 큰 차이는 부수 효과를 허용하느냐다.

표준 메서드: 부수 효과 금지
  Create → 만들기만
  Update → 고치기만
  Delete → 지우기만
  다른 시스템과 통신? 추가 동작? 안 됨.

사용자 메서드: 부수 효과 허용
  이메일 보내기, 백그라운드 작업 시작, 여러 리소스 한꺼번에 수정...
  뭐든 가능. 대신 "이게 무슨 옆동작을 한다"를 문서에 명확히 적어야 함.

비유: 은행 창구

표준 메서드 = 일반 창구. 입금·출금·잔액조회만. "이체하면서 보험도 가입" 같은 복합 업무는 안 된다.

사용자 메서드 = VIP 상담 창구. "해외 송금하면서 환율 우대 적용하고 알림까지 발송" 같은 복합 업무 가능. 대신 무엇을 하는지 정확히 설명해줘야 한다.

비유 API/코드 위험·주의
일반 창구 (정해진 업무만) 표준 메서드 (부수 효과 금지) 옆동작 섞으면 의미 왜곡
VIP 상담 창구 (복합 업무) 사용자 메서드 (부수 효과 허용) 문서에 옆동작을 꼭 명시

올바른 분리 — 만들기와 보내기를 떼어놓기

Step 1: CreateEmail (표준 메서드)
  → DB에 초안으로 저장만
  → {state: "draft"} 반환
  → 부수 효과: 없음

Step 2: SendEmail (사용자 메서드)
  → SMTP 서버로 진짜 발송
  → 성공하면 상태를 "sent"로
  → {state: "sent"} 반환
  → 부수 효과: SMTP 통신, 상태 변경

이렇게 나누면, 사용자가 "보내기"를 명시적으로 눌렀을 때만 발송된다. 실수로 발송될 일이 없다.

잘못된 예 vs 올바른 예

잘못된 예 (before):
  UpdateEmail({id: 1, state: "sent"})
  → 수정인 척하면서 몰래 SMTP 발송. 헷갈리고 위험.

올바른 예 (after):
  SendEmail({id: 1})
  → 이름부터 "보낸다"고 명확. 발송은 여기서만 일어남.

부수 효과의 숨은 비용 — 하류 의존성

부수 효과를 허용한다는 건 공짜가 아니다. SendEmail이 "진짜로" 일을 하려면, 내 서버 바깥의 다른 시스템에 기대야 한다. 이메일이면 SMTP 서버, 결제면 PG사, 어떤 건 외부 DB나 3rd party API.

이렇게 내 동작이 기대고 있는 바깥 시스템을 하류 의존성(Downstream Dependency)이라고 부른다. (하류 의존성 = 내 API 호출이 속으로 의지하는 외부 시스템. 강물 아래쪽에 있는 물레방아처럼, 내가 물을 흘려보내야 그쪽이 돈다. 0장 용어집 참고.)

원리는 단순하다. 기대는 곳이 늘어날수록, 실패할 수 있는 지점도 같이 늘어난다. 그것도 더하기가 아니라 곱하기로 늘어난다.

비유: 릴레이 배턴

혼자 달리면 넘어질 위험은 나 하나다. 그런데 배턴을 두 번 넘기는 릴레이라면? 나도, 다음 주자도, 그다음 주자도 떨어뜨릴 수 있다. 주자가 늘수록 "어디선가 배턴이 떨어질" 확률은 쭉쭉 올라간다. 하류 의존성이 딱 이렇다. 거치는 시스템이 많을수록 어딘가는 삐끗한다.

비유 API/코드 위험·주의
혼자 달리기 표준 메서드 (외부 통신 없음) 실패 지점이 내 안에만
배턴 두 번 넘기는 릴레이 SendEmail → SMTP → 수신자 알림 거칠 때마다 실패 지점이 곱으로 늘어남
SendEmail 한 번에 매달린 하류 의존성:
  1. DB (상태 저장)      ← 여기서 실패할 수도
  2. SMTP 서버 (발송)    ← 여기서 실패할 수도
  3. 알림 서비스 (통지)   ← 여기서 실패할 수도
  → 세 곳 다 무사해야 성공. 하나라도 삐끗하면 실패.

그래서 부수 효과가 있는 사용자 메서드를 만들 때는, "이 동작이 어떤 바깥 시스템에 기대고 있나"를 같이 떠올려야 한다. 기댈 곳이 많을수록 실패 처리(재시도·타임아웃·에러 응답)를 더 꼼꼼히 챙겨야 한다.


9.3.2 리소스 대상 vs 컬렉션 대상

사용자 메서드가 겨누는 과녁은 셋 중 하나다. 한 개냐, 여러 개냐, 부모 전체냐.

질문 하나면 정해진다. "이 동작은 하나의 리소스에 대한 것인가, 여러 리소스에 대한 것인가?"

하나의 이메일을 보낸다
  → 리소스 대상:  POST /users/1/emails/2:send

모든 이메일을 내보낸다
  → 컬렉션 대상:  POST /users/1/emails:export

사용자의 전체 정보를 내보낸다 (이메일+연락처+설정 다)
  → 부모 리소스 대상:  POST /users/1:export

비유: 아파트 관리 사무소

리소스 대상 = "301호 택배 전달해주세요" (한 세대). 컬렉션 대상 = "3층 전체 소독해주세요" (한 층 전체). 와일드카드 = "전체 아파트 수도 점검해주세요" (모든 층).

비유 API/코드 위험·주의
301호에 택배 (한 세대) /emails/2:send (리소스 대상) 대상이 하나일 때만
3층 전체 소독 (한 층) /emails:export (컬렉션 대상) 같은 컬렉션 여럿일 때
전체 아파트 점검 (모든 층) /users/-/emails:archive (와일드카드) 부모가 여럿일 때

여러 부모에 걸칠 때 — 와일드카드 하이픈(-)

"users/1/emails/2 와 users/2/emails/4 를 동시에 보관하려면?"

POST /users/-/emails:archive

본문에 대상 ID 목록을 담는다:
{
  "emailIds": ["users/1/emails/2", "users/2/emails/4"]
}

하이픈(-)이 "모든 사용자" 라는 와일드카드 역할을 한다.

표준 메서드의 대상도 정리

표준 메서드도 과녁이 정해져 있다. 참고로 같이 본다.

메서드 대상 HTTP 요청
CreateEmail 부모 컬렉션 POST /users/1/emails
ListEmails 부모 컬렉션 GET /users/1/emails
GetEmail 리소스 GET /users/1/emails/2
UpdateEmail 리소스 PATCH /users/1/emails/2
DeleteEmail 리소스 DELETE /users/1/emails/2

9.3.3 무상태 사용자 메서드 — 저장 안 하는 메서드

이번엔 다른 종류다. 아무것도 저장 안 하는 메서드.

번역을 생각해보자. "안녕하세요를 영어로" 라고 보내면 "Hello"를 돌려준다. 이게 끝이다. 서버에 저장되는 건 아무것도 없다.

이렇게 입력만 받아 결과만 돌려주고 저장은 안 하는 걸 무상태 메서드라고 한다. (무상태 = 이전 일을 기억하지 않고, 들어온 입력만으로 답을 내는 성질. 0장 용어집 참고.)

비유: 계산기 vs 금고

무상태 = 계산기. 숫자를 넣으면 답을 주지만, 이전 계산은 기억 안 한다. 유상태 = 금고. 물건을 넣으면 저장하고, 나중에 다시 꺼낼 수 있다.

비유 API/코드 위험·주의
계산기 (기억 안 함) :translate, :validate (무상태) 누가 썼는지 추적 안 됨
금고 (저장함) :send (유상태) 저장하니 GDPR 신경 써야

코드로 보기 — 번역 (worked example)

@post("/text:translate")
TranslateText(req: TranslateTextRequest): TranslateTextResponse;

interface TranslateTextRequest {
  sourceLanguageCode: string;   // "ko"
  targetLanguageCode: string;   // "en"
  text: string;                 // "안녕하세요"
}
interface TranslateTextResponse {
  text: string;                 // "Hello"
}
흐름:
  사용자 → "안녕하세요를 영어로" → API → "Hello" → 사용자
  서버에 저장: 없음
  DB 통신: 없음
  나중에 또 보내면: 또 새로 번역 (기억 안 하니까)

무상태가 GDPR에 유리한 이유

GDPR은 유럽 개인정보보호법이다. 데이터가 어디에 어떻게 저장되는지를 엄격히 따진다. (GDPR = 유럽연합의 개인정보 보호 규정. "함부로 저장·보관하지 말라"는 법. 0장 용어집 참고.)

번역 API가 사용자 텍스트를 DB에 저장한다면?
  → 개인정보 보관 규정을 다 지켜야 함. 부담.

무상태로 만들면?
  → 처리만 하고 바로 버림 → 저장 자체가 없음
  → 개인정보 보호에 유리, GDPR 부담 감소

앵커 리소스 — 무상태에 부모를 붙이기

순수 무상태는 깔끔하다. 그런데 문제가 있다. 누가 번역을 몇 번 했는지 추적이 안 된다. 비용 청구도 안 된다.

그래서 무상태 메서드를 어떤 부모 리소스에 매달아두기도 한다. 이 부모를 앵커 리소스라고 한다. (앵커 리소스 = 무상태 메서드를 묶어두는 부모. 배를 고정하는 닻처럼, "이 동작이 어디에 속하는지" 매어두는 기둥. 0장 용어집 참고.)

// 방법 1: 순수 무상태 — 닻 없음
@post("/text:translate")
// 문제: 누가 얼마나 번역했는지 추적 불가

// 방법 2: 프로젝트에 매달기
@post("/{parent=projects/*}/text:translate")
// 장점: 프로젝트별 비용 청구, 사용량 추적 가능

방법 3 — 모델 리소스에 매달기 (번역기 골라 쓰기)

방법 2는 "누가 썼나"는 풀어주지만, 아직 못 푸는 게 하나 남았다. 번역 결과를 내놓는 번역기가 딱 하나뿐이라는 점이다. 실무에서는 번역 모델이 여러 개일 때가 많다. 빠른 모델 A, 정확한 모델 B, 법률 문서 전용 모델 C 같은 식으로.

순수 무상태(방법 1)는 그냥 /text:translate 하나라서, "어느 모델로 번역할래?"를 끼워 넣을 자리가 없다. 그래서 모델 자체를 리소스로 만든다. 먼저 표준 메서드로 번역 모델을 만들어두고(CreateTranslationModel), 그 모델 리소스에 :translate를 매단다.

비유: 믹서기 여러 대

방법 1은 주방에 믹서기가 딱 한 대. 그냥 "갈아줘"만 가능하다. 그런데 가는 일마다 맞는 믹서기가 다르다. 얼음 전용, 야채 전용, 스무디 전용. 그래서 믹서기마다 이름표를 붙여 진열대(리소스)에 올려둔다. 이제 "야채 믹서기로 갈아줘"처럼 골라서 시킬 수 있다. 번역 모델을 리소스로 만드는 게 바로 이 이름표 붙이기다.

비유 API/코드 위험·주의
믹서기 한 대뿐 /text:translate (방법 1) 어느 모델 쓸지 고를 수 없음
믹서기마다 이름표 CreateTranslationModel + /translationModels/A:translate (방법 3) 모델별로 골라서 번역 가능

코드로 보면 두 단계다. 먼저 모델을 리소스로 만들고(표준 Create), 그 모델에 콜론을 붙여 번역을 시킨다.

// 1단계: 번역 모델을 리소스로 만든다 (표준 메서드)
@post("/translationModels")
CreateTranslationModel(req): TranslationModel;

// 2단계: 만들어둔 모델에 :translate 를 매단다 (사용자 메서드)
@post("/{id=translationModels/*}/text:translate")
TranslateText(req): TranslateTextResponse;

HTTP로 보면 이렇게 모델 이름이 경로에 들어간다.

POST /translationModels/fast-A/text:translate
POST /translationModels/legal-C/text:translate
  → 같은 번역이지만 어느 모델을 거치느냐가 경로로 드러난다
# 모델 리소스에 매단 번역 — 모델 A/B 골라 쓰기
@app.route("/translationModels/<model_id>/text:translate", methods=["POST"])
def translate_with_model(model_id):
    data = request.json
    translated = _translate(data["text"], model_id)  # 모델별로 결과가 달라짐
    return jsonify({"text": translated, "model": model_id})

언제 쓰나. 번역기(또는 추천기·요약기 같은 ML 동작)가 여러 종류이고, 호출하는 쪽이 골라야 할 때다. 앞 연습문제에서 "나중에 번역 모델 여러 개 중 선택은 어렵다"고 했던 게 바로 이 방법으로 풀린다. 모델이 영원히 하나뿐일 게 확실하면 방법 1·2로 충분하다. 굳이 미리 만들 필요는 없다.

Flask로 보기 — 주소 검증 (무상태)

@app.route("/emailAddress:validate", methods=["POST"])
def validate_email_address():
    """무상태: 어떤 리소스에도 안 묶임, DB 저장 0"""
    address = request.json.get("emailAddress", "")
    is_valid = bool(re.match(r"^[\w.+-]+@[\w-]+\.[\w.]+$", address))
    return jsonify({"emailAddress": address, "isValid": is_valid})
# 앵커 리소스(프로젝트)에 매단 무상태 번역
@app.route("/projects/<project_id>/text:translate", methods=["POST"])
def translate_text(project_id):
    data = request.json
    translated = _translate(data["text"], data["sourceLanguageCode"],
                            data["targetLanguageCode"])
    _record_usage(project_id, len(data["text"]))   # 프로젝트별 비용 추적
    return jsonify({"text": translated})

9.3.4 다 모으면 — Email API 완성형

표준 메서드와 사용자 메서드를 한데 모으면 이런 그림이다.

Email API v1 전체 구조

표준 메서드 (CRUD)        사용자 메서드 (Custom)
┌────────────────┐       ┌──────────────────────┐
│ CreateEmail    │       │ :send     (리소스)    │  이메일 전송
│ GetEmail       │       │ :unsend   (리소스)    │  전송 취소
│ UpdateEmail    │       │ :undelete (리소스)    │  삭제 복구
│ DeleteEmail    │       │ :export   (컬렉션)    │  전체 내보내기
│ ListEmails     │       │ :validate (무상태)    │  주소 검증
└────────────────┘       └──────────────────────┘
       │                          │
       v                          v
  데이터 CRUD만             부수 효과 허용
  부수 효과 없음            자유로운 동작

부분완성 — 빈칸 채우기

다음 사용자 메서드의 콜론 동작명을 채워보자.

"문서를 게시한다"        → POST /documents/789:______
"결제를 환불한다"        → POST /payments/456:______
"계정을 비활성화한다"     → POST /users/1:______

(정답: publish / refund / deactivate. <동사><명사> 또는 동사 하나로 명확하게.)

독립 적용 — 직접 설계

"주문(Order)을 취소한다"는 동작을 사용자 메서드로 만든다면? 대상은 단일 주문이니 리소스 대상이다. 답은 이렇게 된다.

POST /orders/123:cancel

9.4 트레이드오프 — 양날의 검

사용자 메서드는 편리하지만, 함부로 쓰면 독이 된다.

REST 원칙과의 긴장

REST의 핵심 사상은 "세상 모든 것은 리소스다" 이다. 그래서 모든 동작을 리소스에 대한 CRUD로 표현하려 한다.

사용자 메서드는 이 원칙에서 살짝 벗어난다. 사실상 RPC(원격 프로시저 호출) 스타일이다. (RPC = 멀리 있는 함수를 마치 내 컴퓨터 함수처럼 불러 쓰는 방식. "리소스" 대신 "동작"을 직접 호출. 0장 용어집 참고.) 즉 사용자 메서드는 REST와 RPC의 중간지대에 있다.

남용하면 안 된다 — 나쁜 징후들

나쁜 징후 1: 모든 게 사용자 메서드일 때
  POST /users/1:getName     ← GetUser 표준으로 충분한데!
  POST /users/1:changeName  ← UpdateUser 표준으로 충분!
  POST /users:getAll        ← ListUsers 표준으로 충분!

나쁜 징후 2: 리소스 설계가 잘못된 신호
  POST /orders/1:addItem     ← OrderItem 리소스를 만들었어야 함
  POST /orders/1:removeItem  ← DeleteOrderItem 으로 풀 수 있음

올바른 사용
  POST /emails/1:send        ← 상태 전환 + 외부 통신 (표준 불가)
  POST /documents/1:translate ← 계산 작업 (성격이 다름)
  POST /emails:export        ← 일괄 처리 (단일 CRUD 불가)

비유: 청테이프 vs 제대로 된 수리

사용자 메서드 남용 = 파이프가 샜는데 청테이프로 칭칭 감는 것. 당장은 물이 안 새지만, 근본 문제(리소스 설계)는 그대로다.

올바른 접근 = 파이프를 교체하는 것. 리소스 구조를 제대로 짜고, 진짜 필요한 곳에만 사용자 메서드를 쓴다.

비유 API/코드 위험·주의
청테이프로 임시방편 모든 걸 사용자 메서드로 처리 리소스 설계 문제를 가림
파이프 교체 표준 메서드 우선, 필요한 곳만 사용자 근본을 고침

만들기 전 체크리스트

사용자 메서드를 만들기 전 스스로 물어보자.

□ 표준 메서드(CRUD)로 정말 안 되는가?
□ 리소스를 다시 설계하면 표준으로 되지 않는가?
□ 부수 효과가 꼭 필요한 동작인가?
□ API 전체에서 이름·패턴이 일관된가?
□ 사용자 메서드가 너무 많아지고 있진 않은가?
   → 그렇다면 리소스 설계에 문제가 있다는 신호일 수 있다!

9.5 연습문제로 정리

문제 1. 리소스 생성에 부수 효과가 따라와야 한다면?

상황: 주문을 만들면 결제·재고감소·알림이 같이 일어나야 한다.

CreateOrder 하나로 다 하면? Create의 의미가 왜곡된다. "생성"이라더니 실제론 복합 작업이 돼버린다.

올바른 분리:

Step 1: CreateOrder (표준)  → 주문을 "draft"로 저장만
Step 2: PlaceOrder (사용자) → POST /orders/123:place
        → 결제 + 재고감소 + 알림 + 상태 "confirmed"

핵심: 표준 메서드는 "정의된 동작만" 한다는 약속이 있다. 부수 효과가 필요하면 사용자 메서드로 빼서, "이건 옆동작이 있다"고 이름으로 알려주는 게 맞다.

문제 2. 사용자 메서드가 컬렉션을 겨눠야 할 때는?

같은 컬렉션 안의 여러 리소스를 한꺼번에 다룰 때다.

컬렉션 대상:    POST /users/1/emails:export      (사용자1의 모든 이메일)
부모 리소스 대상: POST /users/1:export            (사용자의 모든 데이터)
와일드카드:     POST /users/-/emails:archive     (여러 사용자에 걸쳐)

판단 기준:

한 리소스?     → 리소스 대상
같은 종류 여럿? → 컬렉션 대상
부모 전체 영향? → 부모 리소스 대상
부모가 여럿?    → 와일드카드

문제 3. 무상태 메서드에만 전적으로 기대면 안 되는 이유는?

무상태는 간단하지만 한계가 있다.

1. 비용 추적 불가    → 누가 얼마나 썼는지 모름
2. 확장성 부족       → 나중에 번역 모델 여러 개 중 선택? 어려움
3. 권한 관리 어려움   → 리소스가 없으면 권한을 어디 걸지?
4. 감사/로깅 어려움   → 기록이 안 남아 추적 불가

결론: 순수 무상태는 편하지만 장기적으로 막힌다. 나중에 상태 기반(리소스 연결)이 필요할 수 있으니, 처음부터 앵커 리소스를 고려해두면 현명하다. 단, 당장 필요 없는 기능을 미리 잔뜩 만들 필요는 없다.


핵심 정리

  • 사용자 메서드는 거의 항상 POST를 쓴다. PATCH는 안 쓴다. 안전하고 멱등하면 GET도 가능하지만 드물다.
  • 리소스 경로와 동작은 콜론(:)으로 나눈다. 예: /missiles/1234:launch.
  • 표준 메서드는 부수 효과 금지, 사용자 메서드는 허용. 단 문서에 명확히 적는다.
  • 한 컬렉션 안의 여러 리소스를 다루면 보통 컬렉션을 대상으로 한다.
  • 계산량 많은 작업은 무상태 사용자 메서드로 만들 수 있지만, 나중에 상태가 필요해질 수 있으니 신중히 결정한다.

한눈에 보는 결정 흐름

"이 동작을 API로 제공해야 한다"
  │
  ├─ 표준 CRUD로 가능? ── 예 → 표준 메서드 (끝)
  │                     아니오 ↓
  ├─ 리소스 재설계하면 가능? ── 합리적이면 → 재설계 후 표준 (끝)
  │                          과한 변형 필요 → 사용자 메서드 ↓
  ├─ 부수 효과 필요? ── 예 → 유상태 사용자 메서드
  │                   아니오 ↓
  ├─ 서버에 저장? ── 예 → 유상태   아니오 → 무상태
  │                                       └ 비용추적 필요? → 앵커 연결
  └─ 대상 결정:
       단일 → 리소스 대상   같은 컬렉션 여럿 → 컬렉션 대상   리소스 무관 → 무상태

다음 장 예고

다음 장에서는 한 번에 끝나지 않고 시간이 걸리는 작업을 다루는 방법을 살펴본다. (지금 몰라도 됨.)

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플